[JAVA安全]Java Agent初探
什么是”Java Agent” ?
在Java中,”Agent”(代理)是指一个可以附加到Java虚拟机(JVM)上的程序,它可以监控、修改或扩展JVM执行的应用程序的行为。这个术语的使用源于它的工作方式:它像一个代理一样在JVM和应用程序之间进行工作,而不需要改变应用程序本身的代码。
java Agent主要有2种方式
- 静态Agent:在JVM启动时通过
-javaagent
参数加载。它必须定义一个premain
方法,JVM会在应用程序的main
方法执行之前调用这个方法。 - 动态Agent:在JVM已经运行的情况下附加。它必须定义一个
agentmain
方法,当Agent被动态附加到JVM时,此方法被调用。
静态Agent
创建代理类
1 | import java.lang.instrument.Instrumentation; |
Instrumentation
对象是Java Agent的核心,提供了一系列强大的工具来控制和监视JVM的运行时行为,它允许Java agent访问和修改类和对象的信息。Instrumentation
对象通常在代理初始化时通过premain
方法或agentmain
方法传递给Java代理。
premain
方法在启动 Java 应用程序时,在 main
方法之前调用 premain
方法。这是 Java Agent API 的一个约定。premain
方法有两个参数:
String agentArgs
: 这是传递给代理的参数。这些参数是在启动 JVM 时与代理一起指定的。Instrumentation inst
: 这是Instrumentation
的一个实例,它提供了各种用于修改和检查类和对象的方法。
在 Java Agent 中,除了 premain
方法之外,还可以定义一个名为 agentmain
的方法。这个方法允许你的代理代码在 JVM 启动之后的某个时刻被动态地加载和执行。这通常用于那些不能在 JVM 启动时就加载的场景,或者用于那些需要在运行时动态附加到 JVM 的代理。
premain和agentmain方法的必须是静态方法,且必须满足String,Instrumentation的参数规范
创建清单文件
MANIFEST.MF
1 | Manifest-Version: 1.0 |
Premain-Class用来指定静态代理类,这个类将被查找并在JVM启动之前调用其 agentmain
方法。
Agent-Class用来指定动态代理类,这个类将被查找并在JVM启动之后调用其agentmain
方法。
有以下几点需要注意:
- MANIFEST文件必须以一个空行结束。
- 清单文件中的属性按照需求来写即可,不需要每个都包含,如果你只需要静态agent,那就只写Premain-Class,反之亦然。
- 类名必须是完整类名
打包代理jar
进入包含manifest以及class文件的目录下,输入jar命令进行打包
1 | jar cmf MANIFEST.MF myAgent.jar *.class |
编写主程序
1 | public class Main { |
执行的程序用字节码文件或者打包成jar都可以
代理执行
1 | java -javaagent:myAgent.jar -cp Main_path Main |
这里注意要添加classpath,否则会抛出NoClassDefFoundError
执行结果
这里是以静态agent的方式使用,所以在JVM启动之前只执行了premain方法
动态Agent
编写主程序
因为动态agent是以附加到别的jvm上的工作形式,所以我们需要写一个能持续运行的程序
1 | import static java.time.LocalTime.now; |
编译为Main.class文件
使用Attach API 附加Agent
在刚才的静态Agent编写步骤里Agent JAR文件已经准备好了,现在只需要在另一个Java应用程序中使用Attach API来将这个Agent附加到目标JVM上。
首先,查找你要附加的JVM进程ID。
先运行刚才的Main程序
使用jps来查找进程PID
1 | C:\>jps |
然后,使用Attach API来加载你的Agent。
Attach
1 | import com.sun.tools.attach.VirtualMachine; |
执行attach,返回Main程序查看结果,动态agent附着成功
有一个权限问题,attach api的JVM权限必须 ≥ Main程序的JVM权限,也就是说,你不能用管理员权限运行Main而用普通用户权限去attach,否则会抛出“拒绝访问”的IO异常
阻塞性探究
将MyAgent的premain与agentamin方法修改如下
1 | import java.lang.instrument.Instrumentation; |
静态Agent执行结果
在premain
方法执行完毕之前,Main程序不会执行,因此静态Agent
具有阻塞性
动态Agent执行结果
动态Agent的本质是将attach JVM连接到主程序JVM的运行环境中,相当于2个JVM共享同一片内存区域,因此动态Agent不具有阻塞性
常见应用
字节码增强(静态)
主程序Main类
1 | public class Main { |
Agent类
1 | import java.lang.instrument.Instrumentation; |
inst.addTransformer
方法是 Java Agent中的一个关键方法,用于添加一个类文件转换器(ClassFileTransformer
)到 JVM中。这是用来实现字节码增强的一种方式。
ClassFileTransformer实现类
1 | import javassist.*; |
ClassModifier
- 这是一个实现了
ClassFileTransformer
接口的对象。 - 当 JVM 加载或者重新转换(retransformClasses)一个类时,会回调这个对象的
transform
方法,允许我们修改类的字节码。
有一点很重要,javassist版本要对应JDK的适用范围。比如我用的是JDK 17,所以我用的是最新的3.29.2的javassist。如果你的JDK版本很高,那么javassist对应的版本也要更新才对,否则可能出现各种错误。
清单文件
1 | Manifest-Version: 1.0 |
打包测试
1 | javac -cp .;./lib/* *.java |
成功在sayHello方法执行前与执行后执行insert中的代码
字节码增强(动态)
主程序Main类
1 | public class Main { |
Agent类
这里我将上文使用的ClassFileTransformer实现类
换成了实现ClassFileTransformer接口的匿名类
,快捷一些
1 | import javassist.*; |
Attach API
上述内容中有Attach API,不再赘述
清单文件
1 | Manifest-Version: 1.0 |
打包测试
在上个例子中,在主程序的classpath中指定了javaassist依赖项,但在实战中的环境不一定具备此条件,同时更多的主动权在攻击者手中。所以这次我们将javaassist依赖项直接打包在Agent Jar中。
1 | jar cvmf MANIFEST.MF myAgent.jar *.class ./lib/* |
运行Main,查看PID,最终Attach 一条龙
成功通过动态Agent实现字节码增强
补充
transform 方法的调用时机
类加载时调用:最常见的情况是,在 JVM 加载类时,如果已经通过 addTransformer
方法注册了 ClassFileTransformer
,那么对于每个被加载的类,JVM 都会调用这个 ClassFileTransformer
的 transform
方法。这允许你在类实际被使用前修改其字节码,对应的是上述中静态Agent修改字节码的情况。
类重新转换时调用:当你调用 inst.retransformClasses
方法请求重新转换一个或多个已加载的类时,JVM 也会调用 ClassFileTransformer
的 transform
方法(前提是canRetransform
参数为true
),即使这个类已经被加载。这是为了应用在运行时的字节码修改。
字节码增强的本质
1. 运行时字节码修改
字节码增强发生在类的字节码级别,通常是在类被加载到 JVM 之前(静态增强)或者在类已经加载之后(动态增强)。这意味着你可以在不改变原始源代码的情况下,改变类的行为。
2. 不重新加载类
与重新编译或替换类文件不同,字节码增强并不涉及类的重新加载过程。即便是对于已加载的类,通过 retransformClasses
方法触发的增强操作只是动态替换内存中的类定义,而不会产生 JVM 完全重新加载一次类的行为。
retransformClasses方法并不会触发被重新转换类的static代码块
javassist的ClassClassPath问题
在前面的例子中,静态增强与动态增强在转换方法的不同上,本质的区别就只有动态增强比静态增强多两行代码
1 | ClassClassPath ccp = new ClassClassPath(classBeingRedefined); |
- ClassPool的classpath是由Javassist这类库在Java应用程序运行时动态管理的。ClassPool是一个自定义的类加载和管理机制,它独立于JVM的标准类加载器。
- 通过ClassPool的classpath,你可以动态地添加
(insertClassPath)
、移除或修改类路径。这允许在运行时进行更复杂的操作,如动态地修改类的结构或行为。 - ClassPool可以访问JVM的classpath中的类。当你在ClassPool中查找类时,如果该类在JVM的classpath中,ClassPool可以加载和使用它。但是,如果你在ClassPool中添加新的类路径或修改类,这些变化不会反映到JVM的标准类加载器中
如果没有这两行代码,会报错
1 | javassist.CannotCompileException: [source error] no such class: System.out |
那么问题来了既然ClassPool可以访问JVM的classpath中的类,那为什么会显示no such class: System.out
呢?
开始探索,
首先跟进到searchImports
方法,
不难看出,因为classPool并没有成功获取到System.out这个类(java.lang.System.out也测试过),所以才会抛出compile error: no such class: System.out
但正常情况下java.lang包是默认包含在classpath中的。
接下来,我们需要找到初始ClassPool的classpath,看看里面的情况
回到Agent,跟进ClassPool.getDefault()方法,
该方法会添加系统类路径(包括 Java 标准库和其他基础类路径
),佐证了上述观点
跟进appendSystemPath方法,
继续跟进,
以JAVA 9为分界线,做了2种不同的添加系统类classpath方法的适配
跟进appendClassPath方法,
appendClassPath
方法在ClassPoolTail
类中用于将新的ClassPath
添加到类路径列表的末尾。
当前类ClassPoolTail
下的toString方法刚好能够打印当前ClassPool实例的classpath
而ClassPool
类的toString恰好调用了上述方法,
之后通过打印classPool来查看其classpath
在setBody上一行增加一行代码
1 | System.out.println("classPool's cp: \n"+classPool+"\n"); |
有insertClassPath
1 | [class path: Main.class;<null>;] |
无insertClassPath
1 | [class path: <null>;] |
从这里似乎并不能找到为什么会出现System.out无法识别的原因。因为Main.class和java.lang.System.class,一个是自定义类,一个是java标准库,二者毫无关系。
额外补充一个,静态Agent的
1 | [class path: jdk.internal.loader.ClassLoaders$AppClassLoader@4dc63996;] |
让我们有的放矢地更改一下,既然javaassist找不到System.class,那我们就手动给他System的类路径
1 | if (classBeingRedefined != null) { |
执行结果
这次我们显式地将System.class加入到javaassist的classpath中,发现执行成功,并且效果和之前一样都实现了字节码增强。
通过以上的探究,加之一些资料的查询,我目前的推测如下(不一定准确):
- 在 Java 中,
java.lang
和其他标准库的类通常由 “引导类加载器”(Bootstrap ClassLoader)加载,而由于引导类加载器是 Java 运行时的一部分,它通常不会出现在由应用程序代码打印的类路径列表中。所以,在静态Agent的环境中,既然javaassist能够默认的加载应用类加载器(AppClassLoader), 那么Bootstrap ClassLoader也应该能够被默认加载,从而加载java.lang这样的标准库,但对于我们来说是透明的。并且,javaassist可以共享使用JVM 的classpath(针对静态)。 - 在
动态运行
的JVM环境中,不像静态Agent的ClassPool.getDefault()方法会创建一个包含系统类路径
的ClassPool
,在JVM正在运行时,ClassPool.getDefault()由于某种原因无法获取到系统类路径(在内存中无法找到classpath?)
。 - 当我们去手动地为ClassPool去insertClassPath一个class时,javaassist知道去哪里(内存中)找到该类的字节码 ,之后会调用相应的类加载器,在加载该class时,会产生一系列的蝴蝶效应,自动完成其他类的加载。(这一点没想明白,有点牵强)